Dog-Sitting App โ€” Worked Example

Rover/Wag-style ยท US launch, 5M owners, 5M sitters ยท 250k sits/week ยท 2.5M searches/week ยท geo + time search ยท no double-booking ยท a teaching walkthrough, not a cheatsheet.

1. Requirements

Restate the problem first, because the restatement earns you the right to make assumptions. "We're building a marketplace where dog owners find and book sitters. An owner searches for sitters available in a time window near them and manages their upcoming bookings. A sitter publishes availability and rates and sees their upcoming bookings. Later we'll add recurring walks with a pickup-or-dropoff choice and a lockbox handoff."

The functional scope worth committing to out loud: an owner searches by location and time window and does CRUD on bookings; a sitter adds and removes availability and rates and views bookings. The extension adds recurring walks (weekly MWF or daily), a choice to drop the dog at the sitter's place or have the sitter pick it up, and a lockbox code when they pick up. State what you're cutting, too โ€” payments UI, messaging, reviews, and the categorization of breeds are all out of scope unless they ask.

The non-functional requirements are what actually shape the design, and there are two that matter. Search is a geospatial-plus-time query, and it's the read hot path. Booking needs consistency so a sitter is never double-booked for overlapping times. Everything else follows from those two.

The scale read is the most important sentence, because it tells you what NOT to build โ€” so convert the numbers to rates before you draw anything. 2.5M searches/week is ~4 searches per second. 250k sits/week is ~0.4 bookings per second. Those are astonishingly small numbers: a single Postgres instance handles this on a laptop. 5M owners and 5M sitters is a few GB of profiles. The write volume is negligible and โ€” critically โ€” the contention is low. Unlike Ticketmaster, no single sitter is fought over by ten million people in the same millisecond. So the correctness problem here is real but cheap: a row-level database constraint solves it, with no waiting queue and no distributed locking. Saying this up front, and later defending why you're not reaching for the heavy Ticketmaster machinery, is the L5 signal โ€” right-sizing beats over-engineering.

The trap to avoid: computing these numbers and then ignoring them. Writing "2.5M searches/week" on the board and then drawing microservices, Kafka, CDC and a search cluster for 4 QPS is the most common L4 tell there is โ€” it shows you did the arithmetic as a ritual rather than as an input. You don't have to actually build the small version, but you must acknowledge it: "at 4 QPS a single Postgres does all of this, so let me justify each piece of machinery I add rather than assuming it." Then add the search index because the geo-plus-filter query genuinely needs it, and add async workers because email shouldn't sit on the booking path. Every component earns its place out loud.

2. Architecture

โ–ฌ consistency-critical write path Owner app Sitter app API Gateway + LB Search service geo + time filter Booking service reserve โ†’ confirm Availability service slots, rates Elasticsearch geo_point + filters + rank derived read model Bookings DB shard by sitterId EXCLUDE overlap constraint Sitter DB profiles, availability, rates Redis ยท 3 caches profile ยท search results ยท ratings never: availability Secrets svc lockbox codes: scoped, TTL, audited single-shard txn CDC โ†’ refresh search index

The architecture has three ideas, and each is worth a sentence of defense rather than just a box.

Idea one: search reads from a derived index, not the source of truth. A sitter's location is static, but their availability changes constantly as bookings come and go. So the searchable copy โ€” a denormalized Elasticsearch document holding location, filters, ratings and current availability โ€” is a read model kept fresh by CDC from the sitter and bookings databases, exactly like the tweet-search index. It's eventually consistent by seconds, which is fine for browsing because the booking step re-checks the authoritative database before committing. A stale "available" in search just yields an occasional "sorry, just taken" at booking time, never a double-booking.

Idea two: the write path is separate and owns consistency. Booking goes through its own service into a bookings database, and that database โ€” not application code โ€” is what guarantees no overlap. Keeping the write path distinct from the read path means the heavy search traffic never contends with the correctness-critical booking transaction.

Idea three: shard by the thing you transact against. The bookings table is sharded by sitterId, so every booking for one sitter lives on one shard. That makes the overlap check and the insert a single-shard transaction with no distributed commit โ€” the ACID-plus-colocation lesson made concrete. We'll lean on this in ยง6.

3. Core entities

Schema

Sitter {
  id, name, location: (lat,lng),
  geoCell,            // S2/geohash id
  rates, serviceTypes,
  willTravel, travelRadius
}
Availability {
  sitterId,
  during: tstzrange   // [start, end)
}
Booking {
  id, ownerId, sitterId,
  during: tstzrange,  // the reserved slot
  status: pending|confirmed|cancelled|expired,
  reservedUntil,      // set while pending
  serviceType, locationOption  // sit|walk
}                              // dropoff|pickup

Why these shapes

The decision that ripples through the whole design is representing time as a range (tstzrange), not a pair of loose start/end columns. A range is a first-class value the database can compare for overlap, which is what lets ยง6 enforce non-overlap natively and ยง7 do containment matching cleanly.

geoCell is precomputed from lat/lng so search is an index lookup, not a per-row distance scan. Money is integer minor units, formatted at render. And status plus reservedUntil exist to support the 10-minute hold in ยง8 โ€” a booking has a life cycle, it isn't just created-or-not.

4. Geospatial search (the read hot path)

The query is "sitters within X miles of me, available between T1 and T2." You cannot scan a million sitters and compute distance to each on every search, so you need a spatial index. The shared idea behind all of them is to convert a 2D location into a locality-preserving 1D key, so physically-near sitters share a key prefix and become range-scannable.

Index options (name the trade-off)

OptionTrade
GeohashSimplest, fits any B-tree/Redis. Boundary effects โ€” query target cell + 8 neighbors.
QuadtreeAdapts to density (fine cells in cities). More upkeep.
S2 / H3Production-grade cells, handle boundaries/distortion well.
PostGISGiST index + ST_DWithin radius queries. The pragmatic pick at 5M rows on Postgres.
Elasticsearchgeo_point + geo_distance combined with structured filters and relevance ranking in one query. The pick here โ€” see below.
Redis GEOGEOADD/GEOSEARCH, geohash-backed sorted sets. Sub-ms radius, but no filtering or ranking. A hot cache, not a search engine.

Two-stage query

Run it in two passes for the same reason text search does. First a coarse spatial cut: map the search radius to a set of covering cells and pull the candidate sitters in those cells, taking a million rows down to a few hundred. Then a fine filter on that small set: available in the requested window, not already booked, and rank by distance, rating, and price. Doing the cheap geographic cut first and the expensive time-and-rank filter on the survivors is the pattern.

The index is a CDC-fed read model, stale by seconds โ€” fine, because the booking step re-checks truth.

Why Elasticsearch is the right search-optimized database here

Say the reason out loud, because "I'd use Elasticsearch" without one is a name-drop. Sitter search is never only a geo query โ€” it's proximity AND filters AND ranking, resolved in a single pass. "Boarding, large dogs OK, under $60/night, within 5 miles, sorted by rating." A pure spatial structure (geohash in Redis, S2 cells in a KV store) gives you the radius and then leaves you to filter and rank in application code over the candidate set. Elasticsearch does all three in the engine: geo_point fields indexed as BKD trees for the distance cut, an inverted index for the categorical filters, and a scoring pass over the survivors. That combination is the justification.

The document is denormalized

PUT /sitters/_doc/1234
{
  "sitter_id": 1234,
  "loc": { "lat": 37.77, "lon": -122.41 },  // geo_point
  "services": ["boarding","walking"],
  "dog_sizes": ["small","large"],
  "nightly_rate_cents": 5500,
  "rating_avg": 4.62, "rating_count": 1043,
  "available": [ {"gte":"2026-08-08","lt":"2026-08-11"} ]
}

One flat document joins what is four tables in Postgres โ€” the sitter row, their services, their rates, and their review aggregate. That's the whole point of a search-optimized store: pay the join cost once at write time so the read is a single index hit.

The consequence candidates miss: a change to any of those source tables must re-emit the whole sitter document. That fan-in is what your CDC consumer actually has to handle.

The query

GET /sitters/_search
{ "query": { "bool": {
  "filter": [
    { "geo_distance": { "distance": "5mi",
        "loc": {"lat":37.77,"lon":-122.41} } },
    { "term":  { "services": "boarding" } },
    { "term":  { "dog_sizes": "large" } },
    { "range": { "nightly_rate_cents": {"lte":6000} } }
  ],
  "should": [ { "rank_feature":
      { "field": "rating_avg" } } ]
}}}

filter clauses are boolean and cacheable by the engine, should contributes to score. This is the two-stage query from the card above, expressed declaratively โ€” Elasticsearch picks the cheapest filter to lead with rather than you hand-ordering the passes.

What "CDC โ†’ refresh search index" actually means

Two different things get collapsed into that arrow on the diagram, and separating them is a small piece of depth worth having ready.

The pipeline sense. Postgres commits a write. Debezium (or equivalent) tails the WAL and emits a change event. A consumer rebuilds the affected sitter's denormalized document and issues a bulk upsert into Elasticsearch. Nothing writes to Elasticsearch directly โ€” the search index is a derived store, and treating it as a second source of truth is how you get two systems that disagree with no way to reconcile.

The Elasticsearch sense, which is a real technical term. An indexed document is not immediately searchable. It lands in an in-memory buffer plus a translog. A refresh flushes that buffer into a new Lucene segment and opens it for reading, which is the moment the document becomes visible to queries. The default refresh_interval is 1 second โ€” this is why Elasticsearch is described as near-real-time rather than real-time.

KnobEffectWhen
refresh_interval: 1sDefault. New docs searchable within ~1s.Steady state.
refresh_interval: 30sFewer, larger segments โ†’ much faster bulk ingest, less merge pressure.Backfills and full reindexes.
?refresh=wait_forThe write call blocks until the doc is visible.Read-your-own-writes, e.g. a sitter editing their profile then viewing it.
Chain the staleness budget out loud, because it's the sentence that shows you understand the whole path. A sitter's availability change is visible in search after the CDC lag plus the refresh interval โ€” call it a few seconds. That is acceptable only because search is advisory and ยง6's exclusion constraint is authoritative: a stale "available" costs an occasional "sorry, just taken" at booking time, never a double-booking. State the budget and then state why it's safe. And know when not to reach for this: if the design had no filters and no ranking, just "5 nearest points," Elasticsearch would be over-engineering and PostGIS or a plain geohash index would be the stronger answer. Naming the case where you'd reject it is what makes the case where you choose it credible.

5. Caching (three caches, and one thing we refuse to cache)

"Add Redis" is not an answer. The answer is which three things, why each one is a good candidate, and how each is invalidated โ€” and a good candidate always has the same two properties: it's read far more than it's written, and it can tolerate being slightly wrong. Anything that fails the second test doesn't get cached no matter how hot it is.

1 ยท Sitter profile, by ID

sitter:1234  ->  { name, photos, bio,
                   rates, services, ... }

Why it's a good candidate. Every search result renders a profile card, and every result page fans out to dozens of these, so the read-to-write ratio is enormous. A sitter edits their bio maybe monthly. The data is also self-contained, so there's exactly one key to worry about.

Invalidation: event-driven, on the CDC stream you already built. The same change event that re-indexes the Elasticsearch document also writes the profile cache. No extra plumbing. Prefer update-in-place over delete so a popular sitter's key is never briefly absent.

TTL of a few hours as a backstop against a dropped event, not as the primary mechanism.

2 ยท Search results, by quantized geo + filter set

Why it's a good candidate. Search is the read hot path and it's the most expensive query in the system. Demand is also heavily clustered โ€” on a holiday weekend, thousands of owners in the same city issue near-identical queries within minutes of each other.

The catch that makes or breaks it: raw coordinates give a ~0% hit rate. 37.7749 and 37.7751 are neighbours in the real world and different keys in Redis. So you quantize the key before you use it.

raw:  lat=37.77490 lng=-122.41940 r=5mi
      svc=boarding size=large rate<=$60
      2026-08-08โ†’10  sort=rating

key:  sitters:v1:cell=9q8yy:svc=boarding
      :size=lg:rate_bucket=75

A 5-character geohash is roughly a 5km box, so a whole neighbourhood collapses onto one key. Filters are canonicalized into a fixed, sorted order so the same logical query can't produce two different strings.

Note what is absent from that key: the dates, the radius, and the exact price. Removing them is the whole design, and the next subsection is about why.

Two details that make the search cache actually work

Cache a superset, re-filter precisely on read

Because you snapped the centre to a cell, the cached result isn't centred on the real user. So query Elasticsearch with a padded radius, cache that wider set, then compute exact distances and re-sort in the application layer before responding. The cache buys you the expensive engine round trip; the app layer restores per-user precision. You get sharing without lying about distance.

Cache IDs, not documents

Store an ordered list of sitter_ids, then hydrate each from cache #1. Otherwise the same sitter's blob is duplicated across dozens of search keys and a single profile edit forces you to hunt down every one of them. With IDs, a profile edit invalidates one key and every search result is instantly correct.

The risk to name unprompted: key cardinality. Every filter multiplies the key space, and a key read once cost more to write than it saved. Mitigation โ€” only cache the common shapes (page 0, default sort, one or two filters); deep pages and exotic filter combinations go straight to Elasticsearch.

Designing the key for reuse: are we caching geography, or availability too?

This is the question that decides whether the cache is worth having, so answer it explicitly rather than letting it sit implied. The cached value is a geography-and-static-attributes candidate set. Availability is deliberately not in it.

The reason is that the two dimensions change at wildly different rates, and a cache key is only as reusable as its most volatile component. A sitter's location, services, and rates change maybe monthly. Their availability changes every time anyone books them. Worse, availability is queried as a date range, and date ranges are nearly unique per user โ€” one owner wants Aug 8โ€“10, the next wants Aug 9โ€“12, and those are different keys holding almost identical answers. Bake dates into the key and you've built a cache with a hit rate near zero that also goes stale in seconds. Both failure modes at once.

So split the query along its own natural seam.

StageWhat it answersWhere it runsVolatility
1 ยท Candidate setWho is near here and matches the static filters?Redis, cached, sharedChanges monthly
2 ยท AvailabilityWhich of those few hundred are free Aug 8โ€“10?Live, per requestChanges constantly
3 ยท RankExact distance, rating, price orderingApp layer, per requestPer user

Stage 1 is expensive and shared. Stage 2 is cheap and personal โ€” it's one indexed query, WHERE sitter_id IN (...) AND during && requested_range, over a few hundred IDs rather than a million. You've moved the volatile dimension out of the cache and onto a query that was always going to be fast. And this keeps the promise from the card below, that availability is read from the source of truth and never cached.

Three techniques for making cache entries reusable

1 ยท Cache composable units, not whole answers

Radius varies by user โ€” 5 miles, 10 miles, 25 miles. If radius is in the key, those are three entries covering overlapping ground.

Instead, cache per cell and assemble per request. Store one entry per geohash cell, then a 10-mile query fetches the covering set of cells and unions them. A 25-mile query reuses every cell the 5-mile query already warmed.

This is the general move: make the cached unit smaller than the question. Small units get shared across many questions; whole answers get shared across none. It's the same reason you cache sitter IDs and hydrate profiles separately.

2 ยท Bucket every continuous dimension

"Under $60" and "under $65" are different keys and nearly identical answers. Round the filter up to a bucket boundary โ€” 50, 75, 100 โ€” cache the wider set, then apply the exact threshold in the app layer.

You've already done exactly this to latitude and longitude. Bucketing is the same trick applied to a different axis, and it works on any continuous input: price, rating floor, travel radius, minimum review count.

Always round in the direction that produces a superset, never a subset. A superset can be filtered down to the truth; a subset silently hides valid sitters and you'll never see the bug in a dashboard.

3 ยท Drop low-selectivity filters from the key entirely. If 90% of sitters accept large dogs, then size=lg barely narrows the result but still doubles your key space. Leave it out, over-fetch, and filter it in the app layer. The test for whether a filter earns a place in the key is not "does the user set it" but "does it change the answer enough to justify halving the reuse of every other entry." Run that test per filter and most designs end up with two or three key dimensions, not eight.

All three techniques are the same idea in different clothes: the cache stores a generous, slow-changing superset, and the request narrows it precisely. Every dimension you can move from the key into the app layer multiplies your hit rate, and the cost is a slightly larger payload and a few lines of filtering โ€” which is a trade you should take almost every time.

A concrete payoff worth stating: because dates left the key, the cached set only goes stale when a sitter joins, leaves, moves, or edits their rates. That's rare, so the TTL rises from ~60 seconds to several minutes, and you can now invalidate it on the CDC event too, since the volatile input is gone. Removing the fast-changing dimension didn't just improve the hit rate, it upgraded the invalidation strategy from "hope" to "event-driven."

3 ยท Review aggregates

sitter:1234:reviews -> { sum: 4820,
                         count: 1043 }
avg = sum / count = 4.62

Why it's a good candidate. Every search result and every profile view shows the rating, and recomputing an average over a thousand rows per render is pure waste. Reviews arrive rarely relative to how often the number is read.

Invalidation: don't invalidate โ€” increment. Store the components rather than the computed value, so a new 5-star review is HINCRBY sum 5 and HINCRBY count 1. That's an atomic O(1) update that never touches Postgres. An edited review is a delta; a deleted one decrements both.

Long TTL (~24h) underneath as a correctness backstop: counters drift if an event is dropped or double-consumed, and expiry forces a periodic recompute from the source that heals the drift.

The one we refuse to cache: availability & booking state

This is the deliberate omission, and volunteering it is worth more than the three caches combined. Availability is the consistency-critical data in this system. A cached "available" that is actually booked produces a double-booking โ€” the exact failure ยง6 exists to make structurally impossible. Caching it would reintroduce, at the cache layer, the bug we removed at the database layer.

The line to say: stale in search is fine, stale at write time is not. Search may show a sitter who just went offline, because the booking request re-reads the source of truth and fails cleanly with "just taken." The cache is allowed to be optimistic precisely because the constraint is pessimistic.

Invalidation, in one table

CacheTTLPrimary invalidationTolerated staleness
Sitter profilehours (backstop)CDC event โ†’ update in placeSeconds. A stale bio is harmless.
Search candidate set
(geo + static filters, no dates)
minutesTTL, plus best-effort CDC eviction of the affected cellsMinutes. Only wrong if a sitter joins, leaves, moves or re-prices.
Review aggregates~24h (backstop)Atomic increment on review eventSeconds. Nobody notices 4.62 vs 4.63.
Availabilitynot cachedโ€”Zero. Read the source of truth.
Why the search cache is TTL-driven and the other two are event-driven. Targeted invalidation requires knowing which keys contain a given entity. For a profile that's one key, so events work. For search results one sitter appears in many keys spanning every filter combination that matched them, so you accept a TTL and let the write path catch the error. Note how much that improves once dates leave the key: a sitter maps to a small, computable set of cells, so CDC eviction becomes partially possible as a best-effort optimization on top of the TTL. The general rule to state: invalidate by event when the key set is small and knowable, by TTL when it isn't, and don't cache at all when the data must be correct. Also, prefer updating a key over deleting it โ€” deleting a hot key makes every concurrent request miss at once and stampede the database, which is a cache causing the outage it was added to prevent.

6. No double-booking (the Ticketmaster lesson, right-sized)

The core guarantee is that no two active bookings for the same sitter overlap in time. The instinct people reach for is a check-then-insert in application code: read the sitter's bookings, see if the new slot conflicts, and if not, insert. That instinct is wrong, and it's wrong in the exact way Ticketmaster was โ€” between your read and your write, another request can slip in and book the same slot, so two "successful" bookings overlap. The fix is the same principle: make correctness a property the datastore enforces atomically, not something the application hopes it checked in time.

Enforce it in the database

CREATE EXTENSION btree_gist;
ALTER TABLE bookings
  ADD CONSTRAINT no_overlap
  EXCLUDE USING gist (
    sitter_id WITH =,
    during    WITH &&      -- ranges overlap
  ) WHERE (status IN ('pending','confirmed'));

This says two active rows with the same sitter_id and overlapping ranges cannot both exist. The database rejects the second insert atomically. A double-booking becomes structurally impossible, not merely discouraged.

Why it's simpler than Ticketmaster

Because we shard by sitterId, all of a sitter's bookings sit on one shard, so the constraint check and the insert are a single-shard transaction โ€” no two-phase commit. And because contention is low, there is no waiting queue and no Redis lock by default. One sitter isn't a hot key the way one concert is, so a row-level constraint fully serializes the rare conflict.

The senior move is to say you considered the Ticketmaster machinery and rejected it on the scale read, rather than either forgetting consistency or over-building for it.

Why shard by sitterId and not by region

Region is the intuitive answer for a geographic product, and it's wrong here. Four reasons, in ascending order of how much they matter.

The two obvious problems

Skew. Population is not uniformly distributed and neither are dog sitters. The Bay Area, NYC and LA would hold orders of magnitude more sitters and bookings than Montana. Region sharding hands you a permanently hot shard and a permanently idle one, and you cannot fix it by adding hardware because the imbalance is in the key. Hashing on sitterId distributes evenly by construction.

Time zones make it worse. Booking traffic peaks in the evening, local time. A region shard therefore gets slammed for a few hours and idles for the rest of the day, and every region shard peaks separately. Hash sharding smears that load across the whole cluster at all times, so you provision for the average instead of for each region's peak.

Boundaries are real, and they leak

The schema has willTravel and travelRadius. A sitter five minutes from a region border serves owners on both sides of it. Under region sharding, which shard holds them? Any answer you pick makes cross-border queries fan out to two shards, or forces you to duplicate the sitter, which reintroduces the consistency problem you sharded to avoid.

Region boundaries are also a business decision. "Split California into North and South" is a product conversation that turns into a data migration. Hash slots rebalance mechanically and nobody has to have an opinion.

The decisive reason, and the one to lead with: shard on an immutable key, because the shard key defines the transaction boundary. ยง6's exclusion constraint only works if every booking for a sitter lives in one database. sitterId guarantees that forever, because a sitter's ID never changes. Region seems to guarantee it too โ€” right up until a sitter moves to another city. Now their entire booking history has to migrate across shards, and while that migration is in flight the overlap constraint is unenforceable, because the rows it must compare are split across two databases. A sitter relocating should be a profile update. Under region sharding it becomes a distributed migration that can double-book someone. Pick a shard key that cannot change, or your correctness guarantee has an expiry date.

And the argument for region sharding doesn't survive contact with the architecture anyway. The appeal is locality โ€” keep geographically-near sitters together so proximity searches stay on one shard. But search never touches this database. It goes to Elasticsearch (ยง4), which maintains its own geo index over a derived copy. The read path that would have benefited from geographic colocation isn't reading from here at all, so region sharding pays every one of the costs above and collects none of the benefit.

Two honest caveats worth volunteering. First, region sharding is right when the driver is data residency rather than performance โ€” if GDPR requires EU sitter data to physically stay in the EU, that's a legal constraint that outranks all of the above, and you'd shard by region at the top level and by sitterId within it. Second, at 5M sitters and 0.4 bookings/sec you wouldn't shard anything yet. Profiles are a few GB and fit comfortably on one primary with read replicas. Bookings are what grow without bound, so bookings are what you shard. Saying "I'd shard bookings by sitterId and leave sitters unsharded until there's a reason" is a stronger answer than sharding everything reflexively.

Notice the constraint uses && on ranges, and the ranges are half-open [start, end). That detail matters: a booking ending at 5:00 and another starting at 5:00 must not count as overlapping, and half-open intervals give you that for free โ€” [3,5) and [5,7) don't overlap, but [3,5) and [4,6) do. Getting the boundary convention right is the difference between a clean "back-to-back bookings are allowed" and an annoying phantom conflict.

"SQL, for ACID properties" is not an answer โ€” and the row-lock trap

When the interviewer asks how you prevent two simultaneous bookings, "I'd use a SQL database for ACID" is the single most common way to lose the point. ACID is a set of properties, not a mechanism. Atomicity says your transaction is all-or-nothing. It says nothing about whether two transactions running concurrently can both decide the sitter is free. At the default READ COMMITTED isolation they absolutely can. Name the mechanism.

The natural next answer is "lock the row," and it contains a trap worth understanding, because it's where a sharp interviewer will take you.

SELECT ... FOR UPDATE locks rows that exist. The conflicting booking doesn't exist yet. That's the whole problem. You cannot lock a row that hasn't been inserted, so locking your conflict-check query locks nothing at all.
T1: SELECT * FROM bookings
    WHERE sitter_id=1 AND during && '[10:00,11:00)'
    FOR UPDATE;              -- 0 rows returned, 0 rows locked
T2: (same query)             -- 0 rows returned, 0 rows locked
T1: INSERT ...               -- succeeds
T2: INSERT ...               -- also succeeds.  double-booked
Both transactions dutifully took a lock on the empty set and proceeded. This is the classic phantom problem, and it's the reason row-level locking on its own does not solve booking systems. If you say "row lock" without saying which row, this is the follow-up you'll get.

Three mechanisms that actually work, ranked

1 ยท Exclusion constraint (lead with this)

The version at the top of this section. There is no read-then-write, so there is no window to race in โ€” the second INSERT fails at the index level, atomically.

Why it's the strongest answer: you can't forget to take a lock, a new code path can't bypass it, and a batch script written two years from now inherits the guarantee automatically. The rule lives in the schema, not in every developer's memory.

Cost: Postgres-specific (needs btree_gist and range types). Say so, then offer #2 as the portable fallback.

2 ยท Lock the parent row

If you must use locking, lock something that exists. The sitter row does.

BEGIN;
SELECT id FROM sitters
  WHERE id = 1 FOR UPDATE;   -- real row
SELECT 1 FROM bookings
  WHERE sitter_id=1
    AND during && '[10:00,11:00)';
-- if none:
INSERT INTO bookings ...;
COMMIT;

Every booking attempt for that sitter now queues behind one lock, making check-then-insert atomic. It serializes even non-overlapping bookings, which sounds bad and is completely irrelevant here โ€” at well under one booking per second across millions of sitters, per-sitter contention is effectively zero. Say that out loud; it converts a limitation into a scale-read justification.

3 ยท SERIALIZABLE isolation. Postgres will detect the write skew and abort one of the transactions. Correct, and the most portable of the three in spirit, but it pushes retry logic into every booking path and costs throughput on all transactions to fix a conflict that occurs rarely. Worth naming as the option you considered and rejected on cost.

A fourth, only if you discretized time into fixed slots (ยง7 option B): a plain UNIQUE (sitter_id, slot_id) index does the whole job, which is exactly the Ticketmaster seat model. That's a real argument in favour of discretization, and worth mentioning when you weigh the schema options.

The thirty seconds that scores. Don't name one mechanism โ€” name all of them and rank them. "The strongest version is an exclusion constraint on (sitter_id, during), because it removes the check-then-act entirely rather than protecting it. If the database didn't support range types I'd take a FOR UPDATE lock on the sitter row, not on the bookings query, because locking a query that returns zero rows locks nothing and phantoms slip straight through. SERIALIZABLE also works but buys me retry logic on every booking to solve a rare conflict." Ranking options with reasons is the difference between knowing an answer and having judgement.

The gap the constraint does not close

Read the constraint carefully and notice what it never mentions: the availability table. It compares bookings to other bookings, and nothing else. So this insert succeeds.

sitter declares availability:  [08:00, 21:00)
owner books:                   [03:00, 04:00)   -- 3am

No other booking overlaps 3am, so the exclusion constraint is satisfied and the row commits. You've just booked a sitter outside the hours they offered. Your display logic would never have rendered that slot, but display is a UI concern โ€” anything hitting POST /bookings directly bypasses it, as does a retry, a migration script, or an internal admin tool. Never let a correctness rule live only in the code that draws the button.

Booking correctness is therefore two independent checks against two different things, and they need two different mechanisms because they're two different races.

CheckAgainstRaceMechanism
No double-bookingOther bookingsThe conflicting row doesn't exist yetExclusion constraint
Within offered hoursAvailabilityThe sitter could delete the window mid-transactionSELECT โ€ฆ FOR UPDATE
This is where SELECT FOR UPDATE finally earns its place, and the contrast with the trap above is the thing worth internalizing.
BEGIN;
-- this row EXISTS, so the lock actually locks something
SELECT id FROM availability
 WHERE sitter_id = 1
   AND during @> '[10:00,11:00)'::tstzrange   -- containment
 FOR UPDATE;
-- 0 rows โ†’ reject: the sitter never offered this window

INSERT INTO bookings ...;   -- constraint handles booking-vs-booking
COMMIT;
Same tool, opposite verdict. Locking the bookings conflict query is useless because the row it would need to lock hasn't been inserted. Locking the availability row works perfectly, because it exists and the lock stops the sitter withdrawing it mid-transaction. Knowing which race each mechanism solves is the actual understanding being tested โ€” "use a lock" and "use a constraint" are both right answers to different halves of this problem, and mixing them up is how you get a design that looks correct and isn't.

Note the two operators differ, and not by accident. && is overlap, used by the constraint because any intersection between two bookings is a conflict. @> is containment, used here because the booking must fit entirely inside the offered window. That's ยง7's "overlap for search, containment for booking" rule appearing as literal SQL.

Handling the rejection

When the constraint rejects an insert, Postgres raises SQLSTATE 23P01 (exclusion_violation). Catch that specific code and return a clean 409 Conflict with refreshed availability attached, so the client can re-render immediately. Letting it propagate as a generic exception gives the owner a 500 for what is a completely normal, expected outcome in a marketplace โ€” and a 500 will also trip your alerting, so every lost race becomes a page. Distinguishing expected conflicts from actual failures is a small detail that reads as production experience.

7. Deep dive โ€” modeling and displaying time slots

This is the follow-up: an owner searches for "1/1 4โ€“6pm," a sitter offers "1/1 3โ€“5pm," and you have to decide how to store, match, and display these. It's deceptively deep, and the schema choice drives everything downstream.

Three schema options, with the trade-offs

A ยท Continuous ranges (start/end)

Store each availability window as one [start, end) range row. "1/1 3โ€“5pm" is a single row. A booking is a sub-range of it.

B ยท Discretized fixed slots

Chop time into fixed buckets (say 30-min). Availability is the set of open slot ids; a booking reserves specific slot ids.

C ยท Recurring rules. Store "available weekdays 9โ€“5" as an RRULE plus exception overrides. Compact for regular schedules and matches how sitters actually think, but you still have to expand it into option A or B to check a concrete date, so it's a layer on top, not a replacement.

The answer that scores: store precise, display discrete. Model the source of truth as continuous ranges (option A) because it's exact and it's what the overlap constraint needs, but render a discretized slot grid (option B) in the UI because that's what users can actually click. The owner's "4โ€“6pm" selection snaps to, say, 30-minute increments on the way in, then becomes a precise range for storage and matching. You get option B's usability and option A's exactness, and you've turned "which schema" into "the right schema for each layer."

Matching: overlap for search, containment for booking

Here's the subtlety in your example. The owner wants 4โ€“6pm; the sitter is available 3โ€“5pm. These overlap (4โ€“5pm is common), but the sitter cannot cover the whole 4โ€“6pm request. So the matching rule depends on the intent. For search/browse, overlap is the right filter โ€” show sitters who have any availability intersecting the owner's window, because the owner may be flexible. For an actual booking, you need containment: the requested range must fit entirely inside an availability window minus existing bookings. 4โ€“6pm is not contained in 3โ€“5pm, so this sitter is not a full match for that booking.

What you show is then a product decision worth naming out loud. You can exclude partial matches, or you can surface them helpfully โ€” "This sitter is free 4โ€“5pm of your 4โ€“6pm request" โ€” and let the owner shorten their window or pick another sitter. Displaying the partial overlap rather than silently dropping the sitter is usually the better marketplace experience.

The rule maps directly onto two Postgres range operators, which is a satisfying place for it to land.

search:   av.during && :requested    -- overlap:     any intersection counts
booking:  av.during @> :requested    -- containment: must fit entirely inside

And note that containment is a rule you have to enforce, not merely one you match on. The exclusion constraint in ยง6 compares bookings to other bookings and never looks at availability, so containment needs its own check โ€” a FOR UPDATE lock on the availability row, inside the booking transaction. ยง6 works through why that lock is the right tool here and the wrong tool for the double-booking half.

The timezone gotcha, and the display format

"1/1 4โ€“6pm" is ambiguous until you attach a timezone, and this is the correctness bug that bites financial and scheduling apps alike. Store everything as timezone-aware timestamps (UTC under the hood), and render in the relevant local zone โ€” the walk happens at the sitter's wall-clock time, so that's usually the anchor. On screen, show the human form the owner typed ("Fri Jan 1, 4:00โ€“6:00 PM") plus the duration, and keep the machine form (the range, in UTC) for matching. The half-open convention from ยง6 carries into display too: a 4โ€“6pm slot and a 6โ€“8pm slot are adjacent, not overlapping, so a sitter can legitimately offer both back to back.

Do not store remaining availability โ€” derive it

This is the design decision that separates the two versions of this system, and it's easy to get wrong because the wrong answer feels more "efficient." The question is what happens to a sitter's availability row when a booking lands inside it.

โœ— Materialize the subtraction

availability: [08:00, 21:00)
  โ†“ booking arrives 10:00โ€“11:00
availability: [08:00, 10:00)
              [11:00, 21:00)   -- split, stored

The booking service writes the booking and rewrites the availability rows, so "what's free" is a cheap read. This is the intuitive design and it's what most whiteboards show.

It requires a correct split on booking, a correct merge on cancellation, and a correct rewrite on modification. And it demands that two tables stay in agreement forever.

โœ“ Keep base + bookings, subtract on read

base:     [08:00, 21:00)      -- never mutated
bookings: [10:00, 11:00)
free   =  base MINUS bookings
       =  [08:00,10:00), [11:00,21:00)

Availability is a computed view, not a stored fact. Booking inserts one row and touches nothing else. The exclusion constraint on bookings alone is now sufficient for correctness, because bookings are the only mutable state.

Cost: one range subtraction per read, over a handful of rows. At this scale that's free, and it caches trivially.

The argument that wins it is cancellation, which is the operation people forget to test their schema against. Under the stored design, cancelling the 10โ€“11 booking means locating the two rows you split, merging them back into one contiguous [08:00, 21:00), handling the case where only one side is adjacent, and doing all of it transactionally. Under the derived design, cancellation is flipping one status field โ€” availability is instantly correct again because it was never a separate fact that could be wrong.

The principle, stated generally: two facts that must agree can always disagree. Storing remaining availability creates a second copy of information already implied by base โˆ’ bookings, and every copy needs a correct update path for every operation, forever. Deriving it makes disagreement structurally impossible rather than merely prevented by careful code. Notice this is the same move as ยง6's exclusion constraint โ€” instead of implementing a guarantee, remove the need for it. That's the instinct interviewers are actually probing for, and it generalizes far past this problem.

The one legitimate exception, and it's already in the architecture. Elasticsearch does need materialized availability, because you cannot efficiently filter on a value computed per query. That's fine, because it's an explicitly derived read model that's allowed to be seconds stale and gets rebuilt by CDC. So the line to draw is: materialize for search, where staleness is acceptable and the booking step re-checks; compute for booking, where it isn't. A worker updating the search index's availability is correct. That same worker updating the availability that booking decisions read is a race condition โ€” between the booking committing and the worker running, the source of truth says the sitter is still free.

Watch for this in your own diagrams. If an async worker consuming from a queue is what updates availability, and the booking path reads that availability, you have made your consistency-critical data eventually consistent with itself. The exclusion constraint can still save you if bookings are what it checks โ€” but if the check reads a worker-maintained table, nothing does.

8. Deep dive โ€” holding a booking for 10 minutes

The second follow-up: when the owner presses Book, hold the slot for 10 minutes while they finish checkout, then either confirm or release it. This is the Ticketmaster reserve-then-pay pattern, and the interesting part is doing it without a second reservation store and without breaking the ยง6 constraint.

The two-step flow

Reserve โ†’ confirm

POST /bookings   (press "Book")
  Idempotency-Key: <uuid>
  โ†’ insert Booking
      status = 'pending',
      reserved_until = now() + 10 min
  โ†’ 201 { bookingId, reserved_until }

POST /bookings/{id}/confirm  (after pay)
  โ†’ if reserved_until > now():
       status = 'confirmed',
       reserved_until = null
     else: 409 hold expired

Why the hold is just a pending row

The pending booking is a real row, and because the ยง6 exclusion constraint covers status IN ('pending','confirmed'), a hold blocks other overlapping holds and bookings the instant it's inserted. So two owners pressing Book at the same time for the same slot can't both get a hold โ€” the database rejects the second, exactly like a confirmed conflict. No Redis, no separate lock. The hold reuses the same correctness machinery.

The trap, and the reason this is a good interview question: you cannot put the expiry in the constraint. You might want the constraint to only block holds where reserved_until > now(), so expired holds stop blocking automatically. But Postgres won't allow it โ€” predicates in an exclusion constraint or partial index must be immutable, and now() is not. So an expired hold still physically occupies the slot until something clears it. That "something" is expiry handled out of band, two ways working together.

Expiring holds: a sweeper plus lazy reclaim

First, a small background job runs every minute or so and flips pending rows whose reserved_until < now() to expired, which removes them from the constraint's active set and frees the slot. That bounds staleness to roughly a minute. Second, and more precise, lazy reclaim on conflict: when a new booking insert fails the exclusion constraint, check whether the blocking row is a pending hold that's already past its reserved_until. If it is, expire it and retry the insert inside the same transaction. Together, the sweeper keeps the table tidy and the lazy path gives an instant, correct answer the moment someone actually wants the slot โ€” you never make a real customer wait a minute for a dead hold to clear.

This is deliberately the same "one reservation store, reclaimed by a timestamp plus a sweeper" design we chose over a Redis TTL in the seat-assignment discussion. A Redis key with a 10-minute TTL would auto-expire with no sweeper, which sounds tempting, but it reintroduces two sources of truth that can drift โ€” the Redis key gone while the DB row lingers, or vice versa. At this contention level the pure-database approach is simpler and has one source of truth, so prefer it and say why. You'd only reach for the Redis hold if booking volume on a single hot slot got high enough that the constraint became a bottleneck, which it won't here.

What the client does

The Book press carries a client-generated idempotency key so a double-tap or a network retry creates one hold, not two โ€” and that same key flows to the payment provider on confirm so a retry never double-charges. The UI shows a countdown timer from reserved_until, and on expiry it tells the owner the hold lapsed and re-checks availability rather than letting them pay for a slot they no longer have. If they confirm in time, the hold flips to a real booking; if they abandon, the sweeper reclaims it silently.

The variant to be ready for: what if booking is a single transaction?

If the interviewer removes payment confirmation and says booking is one atomic operation, the correct answer is yes, delete reserved_until entirely โ€” along with the pending status, the expired status, the sweeper job, and the lazy-reclaim branch. Answer decisively rather than hedging, because the whole hold apparatus exists to solve exactly one problem, and that problem just went away.

The reasoning to say out loud: a hold is the price you pay for a gap between deciding and committing. The two-step flow exists because the owner picks a slot, then goes away to a payment provider for an unbounded amount of time, and during that gap the slot must be neither free (someone else takes it) nor permanently taken (they might abandon). reserved_until is what makes an incomplete booking visible to the exclusion constraint while it's still incomplete. Collapse the gap to a single transaction and there is no incomplete state to represent. The insert either commits or it doesn't.

What you delete

Note this also removes the trap from the callout above. With no expiry there's no temptation to put now() in the constraint predicate, so the immutability problem never arises.

What survives, and why

status stays. Cancellation is a real lifecycle event with nothing to do with holds โ€” an owner cancels a booking three days out. You still need confirmed and cancelled, and the exclusion constraint still filters on active status so a cancelled row stops blocking the slot.

The idempotency key stays. A double-tap or a network retry can still produce two identical inserts, and single-transaction booking does nothing to prevent that. This is the detail people drop when they delete the hold, and keeping it is a nice signal.

The exclusion constraint is untouched. It was always doing the real work. The hold was scaffolding around it.

Then immediately name what buys it back, because the assumption is unrealistic for a real marketplace. Any requirement that reintroduces a gap between request and commit reintroduces the pending state. Payment is the obvious one. But so is sitter approval โ€” on a real Rover the sitter accepts a request rather than being instantly bookable, which is inherently two-phase and where the hold is measured in hours or days rather than ten minutes. Same for a background check, or an owner-sitter message thread before confirming. The structure is identical, only the timeout constant changes. Saying "I'd remove it under your assumption, and here is the single requirement that brings it straight back" shows you understand what the mechanism is for rather than having memorized that booking systems have holds. That's the difference between right-sizing and pattern-matching.

9. Extension: recurring walks, pickup/dropoff, lockbox codes

Three changes. The reassuring part is that the core booking machinery doesn't move โ€” each walk is just another booking flowing through the same overlap constraint, which is the sign the model was factored well.

Recurring walks โ€” materialize, don't just store a rule

Store a WalkSeries with a recurrence rule ("weekly MWF" or "daily") and a window, but materialize individual booking rows for a rolling horizon (the next 4โ€“8 weeks), each carrying a seriesId. You can't enforce non-overlap on a rule you haven't expanded, and each occurrence has to reserve a real slot through the ยง6 constraint. A background job extends the horizon as time passes.

Editing or cancelling is the classic "this instance vs the whole series" problem, the same one Google Calendar has โ€” let an instance override or detach from its series.

Pickup/dropoff and the lockbox secret

locationOption is dropoff (service at the sitter's place, which search already assumed) or pickup (the sitter travels to the owner, so now owner location matters โ€” filter sitters by willTravel radius and leave travel time between back-to-back walks).

The lockbox code is a secret, not a data field. Store it encrypted, scoped to this sitter and this walk, valid only in a window around the scheduled time, revealed to the sitter's app just before the walk, rotated per series or instance, and audited on every access โ€” never in logs or analytics. It's the same short-lived, scoped, rotated credential discipline from fintech, applied to a physical key.

10. Service boundaries โ€” are microservices right here?

The diagram in ยง2 shows separate search, booking and availability services, so be ready to defend that split rather than treating it as the default shape of a system. The generic pros-and-cons list ("independent deployment, but operational overhead") scores nothing. Argue it from this system.

The case for splitting โ€” arguments specific to this design

The read and write paths have genuinely different profiles. Search is high volume, spiky, stateless and eventually consistent. Booking is low volume, transactional and must be exactly right. They need different instance counts, different autoscaling triggers, different latency budgets and different on-call urgency. Independent scaling here is a real benefit, not a slide-deck one.

The service boundary sits where the storage boundary already is. Search owns Elasticsearch, booking owns the sharded Postgres. When a proposed split lines up with a data-store split you already needed, that's evidence the seam is real rather than invented.

Failure isolation on the axis that matters. If Elasticsearch degrades, browsing gets worse but existing bookings can still be viewed and cancelled. If the booking database is saturated, browsing still works and you're only failing the small fraction of traffic that converts. A monolith couples those blast radii.

The case against โ€” costs this design actually pays

You lose transactions across the boundary. This is the big one and it's covered below.

Every hop becomes a partial failure. Booking calling availability over the network means timeouts, retries, and the possibility that the call succeeded but the response was lost. In-process that's a function call that either returns or throws.

Debugging gets harder in exactly the wrong place. "Why did this owner get double-charged" now spans three services' logs and needs distributed tracing to answer.

The scale read argues against it. 4 searches/sec is small. A modular monolith with Elasticsearch as the one separate store would serve this fine, deploy faster and be easier to reason about. A startup building this should probably do that.

The answer that separates a senior candidate: draw service boundaries around consistency requirements, not around nouns. The instinctive decomposition is one service per entity โ€” Sitter, Owner, Booking, Availability, Review. That's noun-driven, and here it would be actively harmful, because ยง6's entire correctness story depends on bookings and the availability they're checked against living in one database. Split "Booking service" and "Availability service" into separate stores and the exclusion constraint is gone. You'd replace an atomic, database-enforced guarantee with a saga, compensating transactions and a reconciliation job โ€” trading a problem the database solves for free for a distributed systems problem you now maintain forever. So: booking and availability are one service sharing one database, and the labels in the diagram are modules within it. Search is genuinely separate because it's already eventually consistent by design and nothing transactional crosses that line.

The rule to state, which generalizes past this problem: anything that must be in one transaction goes in one service. Draw the transaction boundaries first and let the service boundaries fall around them. If a proposed split would force a distributed transaction, the split is wrong โ€” merge those services and find a different seam. Search versus booking passes that test cleanly, because the only thing crossing it is a CDC stream that was always asynchronous.

The pragmatic close, worth saying because interviewers are listening for judgement rather than dogma: "I'd draw these three boxes because they scale and fail differently, but I'd keep booking and availability in one database so the overlap constraint survives. At this scale I'd honestly start with a monolith plus Elasticsearch and split search out when its traffic justified it โ€” the boundary I've drawn is the one I'd extract first, not the one I'd start with."

11. Follow-ups โ€” answers to have ready

How do you prevent two simultaneous bookings? (the question this design exists to answer)

An exclusion constraint on (sitter_id, during) over active statuses. The reason I prefer it to locking is that it removes the check-then-act entirely rather than protecting it, so there's no window to race in and no way for a future code path to bypass the rule. If the database lacked range types I'd take SELECT FOR UPDATE on the sitter row โ€” deliberately not on the bookings query, because that query returns zero rows in the racing case and locking zero rows locks nothing, so phantoms slip straight through. SERIALIZABLE is a third option that works but pushes retry logic into every booking path to solve a conflict that's rare here. And I'd avoid answering this with "SQL, for ACID" โ€” ACID doesn't say concurrent transactions can't both read "free" and both insert, which at READ COMMITTED is exactly what happens.

Your constraint stops overlapping bookings. What stops someone booking a sitter at 3am?

Nothing, as written โ€” that's a real gap and worth catching before the interviewer does. The exclusion constraint compares bookings to other bookings and never references availability, so a 3am booking satisfies it as long as no other booking overlaps 3am. Correctness is actually two checks. Booking-versus-booking is the constraint, because the conflicting row doesn't exist yet and can't be locked. Booking-versus-availability is a SELECT โ€ฆ FOR UPDATE on the availability row using the containment operator @>, which works precisely because that row does exist and the lock stops the sitter withdrawing the window mid-transaction. Same tool, opposite verdict on the two halves. And I'd never rely on the UI not rendering the slot, because anything hitting the endpoint directly bypasses that.

What does the client see when it loses the race?

Postgres raises SQLSTATE 23P01, exclusion violation. I'd catch that code specifically and return a 409 with refreshed availability so the client re-renders straight away. If it propagates as a generic exception the owner gets a 500 for a completely expected marketplace outcome, and it trips alerting too, so every lost race becomes a page. Separating expected conflicts from real failures matters as much for on-call as for UX.

A booking lands in the middle of an availability window. What happens to that window?

Nothing. I don't store remaining availability, I derive it as base availability minus bookings at read time. The reason is cancellation: if I materialize the split I now need a correct merge to undo it, plus a correct rewrite for every modification, and two tables that must agree forever. Deriving it means booking inserts one row and touches nothing else, and cancelling flips one status field with availability instantly correct again. It's the same instinct as the exclusion constraint โ€” rather than implementing a guarantee that two facts agree, remove the second fact. The exception is the search index, which does need materialized availability because you can't filter efficiently on a computed value. That's fine because it's an explicitly stale derived read model. Materialize for search, compute for booking.

Your worker updates availability off a Kafka queue. Is that safe?

Safe for the search index, not safe if booking decisions read it. Between the booking committing and the worker running, that table still says the sitter is free, so a second booking passes its check โ€” I'd have made my consistency-critical data eventually consistent with itself, which defeats the whole point. The async path is the right place for search updates, emails and incrementing the sit counter, because all of those tolerate seconds of lag. The availability that the booking transaction checks has to be in the same database as the booking insert, which is also why I wouldn't split booking and availability into separate services.

Why shard by sitter ID instead of by region? Region seems natural for a geo product.

Three reasons, and one of them is decisive. Region skews badly โ€” the Bay Area would carry orders of magnitude more load than Montana, and time zones make each region shard peak separately so you provision for every local peak instead of the global average. Boundary sitters with a travel radius serve owners on both sides of a line, so region forces fan-out or duplication for exactly those cases. But the decisive one is that the shard key defines my transaction boundary, so it has to be immutable. The exclusion constraint only works if all of a sitter's bookings are colocated. Sitter ID guarantees that permanently. Region guarantees it until a sitter moves house, and then their booking history has to migrate across shards, during which the constraint can't be enforced. I'd also point out that the usual argument for region sharding is geographic locality for search, and search doesn't read this database at all โ€” it reads Elasticsearch. So region pays every cost and collects no benefit. The exception is data residency: if EU data must stay in the EU, that's a legal requirement that outranks all of this.

Does your search cache store availability, or just geography?

Just geography plus the static filters. Availability is applied live afterward. The reason is that a cache key is only as reusable as its most volatile component, and availability is both the fastest-changing field and the one queried as a near-unique date range per user. Putting dates in the key gives you a near-zero hit rate and entries that go stale in seconds. So the cached value is a candidate set of sitter IDs for a cell plus service plus rate bucket, and then one indexed query filters those few hundred IDs by the requested range. That also keeps my earlier promise that availability is never cached.

How do you get more users sharing the same cache entries?

Three moves, all the same idea. Cache units smaller than the question โ€” one entry per geo cell rather than per radius, so a 25-mile search unions cells that a 5-mile search already warmed. Bucket every continuous dimension, rounding price and radius up to boundaries so "under $60" and "under $65" hit one entry, always rounding toward a superset so the app layer can filter down without hiding valid results. And drop filters that barely narrow anything, since a filter matching 90% of sitters halves your reuse while removing almost nothing. The general principle is that the cache holds a generous slow-changing superset and the request narrows it precisely.

If booking were a single transaction with no payment step, would you still need reserved_until?

No, and I'd delete the pending and expired statuses, the sweeper and the lazy reclaim with it. A hold exists purely to represent a decision that's been made but not yet committed, and it's the gap created by the payment provider that makes that state necessary. One transaction, no gap, no state. I'd keep status because cancellation is unrelated to holds, and I'd keep the idempotency key because a double-tap can still create two inserts. Then I'd flag that the assumption is unrealistic for a real marketplace: sitter approval alone brings the pending state straight back, just with a timeout measured in hours instead of ten minutes.

Would you build this as microservices?

I'd split search from booking, because they scale, fail and stay consistent differently, and the seam matches a data-store boundary I already needed. I would not split booking from availability, and that's the more interesting half of the answer โ€” those two must share a database or the exclusion constraint disappears and I'm replacing an atomic guarantee with a saga and a reconciliation job. The rule I'd state is to draw service boundaries around transaction boundaries rather than around nouns. And at roughly 4 searches per second I'd be honest that a modular monolith plus Elasticsearch is probably the right starting point, with search as the first thing I'd extract when its traffic justified it.

Why Elasticsearch rather than PostGIS, when your data is already in Postgres?

Because the query isn't only geographic. It's proximity plus categorical filters plus ranking, and Elasticsearch resolves all three in one engine pass over a denormalized document. PostGIS would handle the radius well but push filtering and relevance ranking back into application code, and the sitter document spans four tables so I'd be joining on every search. The cost I'm accepting is a second store to keep in sync and eventual consistency measured in seconds. If the requirement had been "five nearest sitters, no filters," I'd have taken PostGIS and skipped the pipeline entirely.

What does "refresh" mean in your CDC arrow?

Two things, and I'd separate them. Pipeline-wise it's Debezium tailing the Postgres WAL, a consumer rebuilding the affected sitter's document, and a bulk upsert. Elasticsearch-wise, "refresh" is a specific term: an indexed doc sits in an in-memory buffer and isn't searchable until a refresh flushes it into a new Lucene segment. That defaults to one second, which is why Elasticsearch is near-real-time. I'd raise refresh_interval to 30s during backfills, and use refresh=wait_for on the path where a sitter edits their profile and immediately views it.

A sitter changes their nightly rate. What has to happen?

The rate lives in three places, so three things fire off one CDC event. The Elasticsearch document is re-emitted whole, because the doc is denormalized and I can't patch one field independently of the join that produced it. The sitter profile cache is updated in place. The search results cache is not touched โ€” I can't enumerate which keys contain that sitter, so it ages out on its 60-second TTL. That last one is the interesting answer: I'd rather serve a one-minute-stale price in a result list and show the true price on the profile than build a tag-based invalidation scheme that evicts half the cache on every edit.

Why not cache availability? It's the hottest read you have.

Because it's the one piece of data that has to be right. A cached "available" that's actually booked is a double-booking, which is precisely the failure ยง6's exclusion constraint exists to make impossible โ€” caching it would reintroduce the bug at a different layer. The general rule I'd apply: cache what tolerates being slightly wrong, and read the source of truth for what doesn't. Stale in search is fine because booking re-checks; stale at write time is not.

Your search cache key is a rounded geo cell. Doesn't that give wrong distances?

It would if I returned the cached set directly. Instead I query Elasticsearch with a padded radius, cache that superset keyed by the cell, and then compute exact distances and re-sort in the application layer per request. The cache saves the engine round trip, the app layer restores precision. Without the quantization the hit rate is near zero, because no two users share a raw lat/long.

Why don't you need Redis locks or a waiting queue like Ticketmaster?

Contention. Ticketmaster had ten million people racing for one event, a genuine hot key, so it needed a queue to shed load and locks to serialize. Here a single sitter is booked by at most a handful of owners and rarely at the same instant, so the row-level exclusion constraint fully serializes the rare conflict with no extra infrastructure. Naming that I considered the heavier tools and rejected them on the scale read is the point.

Two owners press Book for the same slot at the same moment โ€” walk the race.

Both try to insert a pending booking for that sitter with overlapping ranges. The exclusion constraint lets the first commit and rejects the second atomically. The loser gets a clean "just taken" and re-queries. Because the hold is a real row covered by the same constraint, there's no window between check and insert to race in.

The owner's 4โ€“6pm request only partially fits a 3โ€“5pm sitter. What happens?

For search I filter by overlap, so the sitter shows up. For booking I require containment, so 4โ€“6pm is rejected against 3โ€“5pm. In the UI I'd surface the partial fit ("free 4โ€“5pm of your window") and let the owner shorten their request or pick another sitter, rather than silently dropping them โ€” better marketplace UX.

What clears an abandoned 10-minute hold, and how fast?

Two things. A sweeper every minute flips expired pending holds to expired, bounding staleness to ~a minute. And lazy reclaim: the next person who tries the slot triggers an immediate expire-and-retry if the blocker is a dead hold, so a real customer never waits. Expiry can't live in the constraint because its predicate must be immutable and now() isn't.

Why store time as a range instead of start/end columns?

Because a range is a value the database can compare for overlap and containment directly, which is what makes the exclusion constraint and the matching queries clean. Two loose columns push all that logic into application code, where it's easy to get boundary conditions wrong. Half-open [start,end) ranges also make back-to-back slots not-overlap for free.

Does a recurring MWF series need distributed transactions?

No. Each occurrence is a separate booking for one sitter, and all of a sitter's bookings live on the same shard, so materializing the series is a set of single-shard inserts, each independently checked by the constraint. If one date conflicts, only that instance fails and you surface it for the owner to adjust.

12. Numbers to drop

Scale

2.5M searches/week = ~4/sec. 250k sits/week = ~0.4/sec. Both trivial. 5M sitter profiles is a few GB, comfortably one Postgres primary with read replicas. The dataset doesn't force exotic infrastructure; the interesting parts are the geo index, the overlap constraint, and the hold, not raw scale.

Search, caches & holds

Elasticsearch is near-real-time by ~1s (refresh_interval), so end-to-end search staleness is CDC lag plus a second. The search candidate-set cache runs a multi-minute TTL because dates are excluded from the key; profile and rating caches are event-updated with hour- and day-scale TTL backstops. Availability is never cached โ€” it's one indexed IN query over the few hundred cached candidate IDs.

The spatial cut takes ~1M candidates to a few hundred per query; the time filter and ranking run on that small set. Holds are 10-minute pending rows; the sweeper touches only rows past reserved_until, a tiny fraction of the table each minute.

13. 30-second recap script

Two paths with different priorities. Search is a geospatial-plus-time read: precompute each sitter's S2 or geohash cell, do a coarse spatial cut to a few hundred candidates, then filter by availability and rank, served from Elasticsearch, which I choose over PostGIS because the query is proximity and filters and ranking together over a denormalized document, and which is a CDC-fed derived index stale by CDC lag plus its one-second refresh interval. On top of that I cache three things โ€” sitter profiles by ID invalidated on the CDC event, a search candidate set keyed by quantized geo cell plus bucketed static filters with dates deliberately left out, so the entry is shared across every date range and the TTL can run to minutes, and review aggregates stored as sum and count so a new review is an atomic increment instead of a stampede-inducing delete. I deliberately do not cache availability, because that's the data that has to be correct, so it's applied live to the few hundred cached candidate IDs. Booking is the consistency path: I model time as half-open ranges and add a Postgres exclusion constraint so two active bookings for the same sitter can't overlap, making double-booking structurally impossible rather than app-checked โ€” not a row lock on the bookings query, because that returns zero rows in the racing case and locking zero rows stops nothing, so the fallback if I had no range types would be a FOR UPDATE on the sitter row instead. I don't store remaining availability at all, I derive it as base minus bookings, so cancellation is one status flip rather than a merge and there are never two facts that can disagree. and I shard by sitter id โ€” not by region, because the shard key defines the transaction boundary so it has to be immutable, and a sitter who moves would otherwise have to migrate shards with the constraint unenforceable in flight. Unlike Ticketmaster there's no hot-key contention, so no queue and no Redis lock โ€” I'd say I considered them and rejected them. For time slots I store precise ranges but display a discretized grid, match by overlap for search and containment for booking, and store timezone-aware timestamps because "4pm" is meaningless without a zone. The ten-minute hold is just a pending booking row with a reserved-until stamp, covered by the same constraint so concurrent holds can't collide; expiry lives outside the constraint via a one-minute sweeper plus lazy reclaim on conflict, and the Book press carries an idempotency key that flows through to payment. The recurring-walks extension materializes each occurrence as its own booking through the same constraint, pickup pulls owner location into eligibility, and the lockbox code is a scoped, short-lived, rotated, audited secret.